[C 기술면접] 설계와 메모리 기본 — 절차지향 vs 객체지향·전역변수·3중 포인터

NOTE

질문 3(절차지향 vs 객체지향), 질문 6(전역변수 사용 시 주의점), 질문 7(3중 포인터를 지양하는 이유)을 묶었다. 공통 주제는 “C에는 언어가 지켜 주는 안전장치가 없으므로, 구조(스코프·소유권·인터페이스)로 안전을 만든다” 이다.


Q3. 절차지향과 객체지향의 차이

한 줄 답변

  • 절차지향(Procedural): 프로그램을 “무엇을 어떤 순서로 처리하는가(함수/절차)” 중심으로 구성한다. 데이터와 함수가 분리돼 있고, 함수가 데이터를 받아 가공한다.
  • 객체지향(Object-Oriented): 프로그램을 “누가(객체) 어떤 책임을 갖는가” 중심으로 구성한다. 데이터와 그 데이터를 다루는 동작을 객체로 묶고, 객체 간 메시지(메서드 호출)로 협력한다.

비교표

구분절차지향객체지향
구성 단위함수(절차)객체(상태 + 행위)
데이터와 함수분리 — 데이터를 인자로 넘김결합 — 객체가 자기 데이터를 관리
설계 시선순서·흐름(top-down)책임·역할·협력
데이터 보호약함(구조체 멤버 공개, 전역 변수 접근)캡슐화(접근 제어자)로 숨김
재사용·확장함수 단위 복사/호출, 변경이 호출부 전파상속·합성·다형성으로 확장, 인터페이스 뒤로 변경 은닉
변경 영향데이터 구조 변경 시 그것을 쓰는 모든 함수가 영향내부 구현 변경이 인터페이스 안에 갇힘
대표 언어C, 초기 BASIC, FortranJava, C++, C#, Python
잘 맞는 곳시스템 프로그래밍, 임베디드, 성능이 중요한 코어, 단순 스크립트규모가 크고 변화가 잦은 업무 시스템, 도메인 모델링

객체지향의 4가지 특징

  1. 캡슐화: 상태를 숨기고 정해진 인터페이스로만 접근
  2. 상속: 공통 속성·행위를 물려받아 재사용
  3. 다형성: 같은 메시지에 대상에 따라 다르게 동작(오버라이딩/인터페이스)
  4. 추상화: 핵심 개념만 드러내고 세부를 숨김

어느 쪽이 더 낫나? (균형 잡힌 답)

  • 우열이 아니라 문제에 맞는 선택이다. 커널·드라이버·프로토콜 스택처럼 메모리·성능을 직접 통제해야 하는 곳은 절차지향(C)이 자연스럽고, 도메인이 복잡하고 변경이 잦은 곳은 객체지향이 유지보수에 유리하다.
  • 절차지향도 모듈화(헤더/소스 분리, static으로 은닉, 접두어)로 규모 있게 설계할 수 있다. 객체지향이어도 책임 분리가 안 되면 절차지향보다 나쁠 수 있다.

C에서 객체지향 흉내내기 (면접 가산점)

C에는 클래스가 없지만 같은 의도를 구조로 구현한다.

OO 개념C에서의 구현
클래스/객체struct + 그것을 다루는 함수들 (foo_create, foo_destroy, foo_do)
캡슐화불투명 포인터(Opaque Pointer): 헤더에는 typedef struct foo foo_t; 선언만, 구조체 정의는 .c에 숨김. static 함수/변수로 파일 내부 은닉
생성자/소멸자_init/_destroy (또는 _create/_destroy) 쌍
다형성구조체 안의 함수 포인터(vtable) 또는 디스패치 테이블
상속/합성구조체의 첫 멤버로 부모 구조체를 포함하면 포인터 캐스팅이 가능(상속 흉내), 또는 멤버로 포함(합성)
인터페이스함수 포인터 집합을 가진 구조체(struct ops)
/* shape.h : 인터페이스만 공개 */
typedef struct shape shape_t;
struct shape_ops { double (*area)(const shape_t *); void (*destroy)(shape_t *); };
struct shape     { const struct shape_ops *ops; };        /* 모든 도형의 공통 첫 멤버 */
 
/* circle.c : 구현은 숨김 */
typedef struct { shape_t base; double r; } circle_t;
static double circle_area(const shape_t *s) { const circle_t *c = (const circle_t *)s; return 3.14159 * c->r * c->r; }
static void   circle_destroy(shape_t *s)    { free(s); }
static const struct shape_ops circle_ops = { circle_area, circle_destroy };
 
shape_t *circle_new(double r) { circle_t *c = malloc(sizeof *c); c->base.ops = &circle_ops; c->r = r; return &c->base; }
 
/* 사용: 다형적 호출 */
double a = s->ops->area(s);       /* s가 원이든 사각형이든 같은 코드 */

→ 참고: 불투명 포인터, 함수 포인터와 디스패치 테이블, 공개 래퍼와 내부 코어 분리

꼬리 질문

질문답변 요지
다형성이 왜 중요한가?호출부가 구체 타입을 모르고 인터페이스에만 의존하게 해, 새 타입 추가 시 기존 코드를 수정하지 않게(OCP) 한다. switch(type) 분기를 없앤다.
상속보다 합성을 권하는 이유?상속은 부모-자식 결합도가 높다(부모 변경이 자식을 깨뜨림). 합성은 필요한 기능만 조립해 유연하다.
SOLID란?SRP(단일 책임), OCP(확장에 열림·수정에 닫힘), LSP(자식이 부모를 대체 가능), ISP(인터페이스 분리), DIP(구체가 아닌 추상에 의존).
절차지향의 단점은?데이터 구조가 바뀌면 흩어진 함수들을 모두 고쳐야 하고, 전역 상태가 늘면 추적이 어렵다.
C++/Java의 객체 오버헤드는?가상 함수 호출(vtable 간접 호출), 객체 헤더, 힙 할당 등. 대부분 무시할 수준이지만 초저지연/임베디드에서는 의식한다.

Q6. 전역변수를 활용할 때 주의할 점

한 줄 답변

전역변수는 어디서든 읽고 쓸 수 있어서 변경 주체 추적이 어렵고, 멀티스레드에서는 데이터 레이스가 되며, 모듈 간 숨은 의존성이 생긴다. 쓰더라도 최소화하고 static으로 가두고, 접근을 함수로 감싸고, 동기화를 건다.

주의점 정리

항목문제대응
스코프 / 링키지전역은 기본 external linkage라 다른 파일과 이름이 충돌하거나 의도치 않게 공유됨파일 내부용은 static, 접두어(mod_ / g_), 심볼 숨김
선언 vs 정의헤더에 변수를 정의하면 include마다 중복 정의 → 링크 에러헤더에는 extern int g_x;(선언), 정확히 하나의 .c에서 int g_x = 0;(정의). GCC 10부터 -fno-common이 기본이라 중복 정의가 에러로 드러남
멀티스레드 경쟁락 없이 읽고 쓰면 데이터 레이스 = UB, count++도 비원자적mutex/rwlock/atomic으로 보호. 또는 스레드별 복사(_Thread_local, __thread, pthread_key_t)
가시성/최적화컴파일러가 레지스터에 캐싱해 다른 스레드/핸들러의 변경이 안 보일 수 있음atomic 사용. 시그널 핸들러가 쓰는 변수는 volatile sig_atomic_t (volatile은 스레드 동기화 수단이 아님)
재진입 불가(Reentrancy)전역/정적 버퍼를 쓰는 함수(strtok, localtime)는 스레드/시그널에서 위험재진입 버전(strtok_r, localtime_r) 또는 호출자가 버퍼를 제공
숨은 의존성 / 결합도함수 시그니처에 안 보이는데 전역 값에 따라 동작이 바뀜 → 테스트·재사용 어려움필요한 상태는 구조체(컨텍스트)로 묶어 인자로 전달, 전역은 설정/싱글턴 최소한
초기화정적 저장 기간 변수는 0으로 초기화되지만, 초기값 식은 상수 표현식이어야 함(C). 다른 전역 값으로 초기화 불가초기화 함수(xxx_init)에서 명시적으로 설정, pthread_once
초기화 순서/수명프로세스 전체 수명. 종료 시 정리 순서가 불분명명시적 init/destroy, 전역 객체 해제 순서 관리
프로세스 경계fork 후 자식은 복사본을 가짐(공유 아님). 다른 프로세스와는 전역을 공유 못 함공유가 필요하면 공유메모리 등 IPC
메모리큰 전역 배열은 .data/.bss를 키워 로드/주소 공간 사용. 읽기 전용은 .rodataconst 적극 사용, 필요 시 동적 할당
false sharing서로 다른 스레드가 쓰는 전역 변수가 같은 캐시 라인에 있으면 성능 저하정렬/패딩(alignas(64)), 변수 분리
디버깅/추적누가 값을 바꿨는지 알기 어렵다접근 함수(getter/setter)에 로그·assert, watchpoint(gdb watch)

권장 패턴

/* mod.h */
int  mod_get_limit(void);
void mod_set_limit(int v);
 
/* mod.c */
static int            s_limit = 100;            /* 파일 내부로 가둠 */
static pthread_mutex_t s_lock = PTHREAD_MUTEX_INITIALIZER;
 
int mod_get_limit(void) { pthread_mutex_lock(&s_lock); int v = s_limit; pthread_mutex_unlock(&s_lock); return v; }
void mod_set_limit(int v) { pthread_mutex_lock(&s_lock); s_limit = v; pthread_mutex_unlock(&s_lock); }
  • 전역을 없앨 수 있으면 없앤다: 컨텍스트 구조체를 만들어 인자로 전달하면 테스트와 멀티 인스턴스가 쉬워진다.
  • 읽기 전용 설정값이면 const로, 초기화 후 변경이 없다면 락 없이도 안전하게(초기화는 pthread_once 또는 시작 시 1회).

→ 참고: extern과 static — 링키지와 다중 파일 공유, 실무 C 코드 관례와 UB 함정 정리 (A7, A9, B8)

꼬리 질문

질문답변 요지
static 전역과 일반 전역 차이?static은 internal linkage — 그 파일 안에서만 보임. 일반 전역은 다른 파일에서 extern으로 접근 가능.
전역 변수가 저장되는 곳?초기값 있음 → .data, 초기값 없음/0 → .bss(실행 시 0으로 채움), const → .rodata.
지역 static 변수와 전역의 차이?수명은 프로그램 전체로 같지만 스코프가 함수 안으로 제한됨. 스레드 안전성 문제는 동일(공유됨).
volatile을 전역에 붙이면 스레드 안전해지나?아니다. 최적화(캐싱) 방지만 하고 원자성/순서 보장은 없다. atomic이나 락을 쓴다.
싱글턴도 전역 아닌가?본질적으로 전역 상태다. 접근 지점을 통제할 뿐이고, 의존성 주입이 어렵다는 단점은 같다.

Q7. 3중 포인터(***)를 지양하는 이유

한 줄 답변

간접 참조 단계가 늘수록 가독성·소유권 추적·오류 처리·안전성이 급격히 나빠지는데, 그 복잡도를 감수할 만한 이점이 거의 없고 구조체 등 더 명확한 대안이 있기 때문이다.

이유

이유설명
가독성/이해 비용***p가 “무엇의 무엇의 무엇”인지 읽는 사람이 매번 해석해야 한다. (*p)[i], **p 같은 우선순위 혼동으로 실수
NULL 검사 폭증각 단계(p, *p, **p)마다 NULL일 수 있어 방어 코드가 3중으로 늘고, 하나라도 빠지면 크래시(SIGSEGV)
소유권/수명 불명확누가 어느 단계를 malloc하고 free하는지 불분명 → 누수, 이중 해제, 해제 순서 오류(안쪽→바깥쪽 순서로 해제해야 함)
오류 처리 복잡중간 단계 할당 실패 시 이미 할당된 것들을 전부 롤백해야 함 (goto 정리 코드 비대)
const 정확성 문제char ** → const char ** 변환은 C에서 안전하지 않아 허용되지 않는다. 단계가 늘수록 const 선언이 지저분해짐
성능각 *는 메모리 로드 1회 — 포인터 체이싱(pointer chasing)으로 캐시 미스 증가, 컴파일러 최적화(별칭 분석) 어려움
API 오용 가능성호출자가 &&p로 무엇을 넘겨야 하는지 헷갈림. 타입 시스템이 실수를 못 막는다
디버깅/테스트 난이도gdb에서 p ***p로 펼쳐야 하고, 단위 테스트 입력 구성이 번거로움
대안이 있음구조체로 묶거나 반환값을 쓰면 간단하다

이중 포인터(**)는 괜찮은가?

필요한 경우에 한해 정당하다.

  • 호출 결과로 포인터 자체를 바꿔야 할 때(출력 매개변수): int alloc_buf(char **out), 연결 리스트 head 변경
  • 문자열 배열: char **argv
  • 이미 널리 알려진 관용구라 리뷰어가 이해하기 쉽다.

3중부터는 대개 “구조가 잘못 설계됐다”는 신호다.

대안

/* 나쁜 예: 문자열 배열을 출력 매개변수로 돌려주기 (***) */
int get_names(char ***out_names, int *out_count);
 
/* 좋은 예 1) 구조체로 묶기 */
typedef struct {
    char **items;     /* 문자열 배열 */
    int    count;
} str_list_t;
 
int  get_names(str_list_t *out);          /* 포인터 한 단계 */
void str_list_free(str_list_t *list);     /* 해제 책임을 한 곳에 */
 
/* 좋은 예 2) 반환값으로 */
str_list_t *get_names(void);              /* 생성/소멸 쌍으로 소유권 명확 */
대안효과
구조체로 묶기개수·포인터를 함께 관리, 단계 축소, 해제 함수를 한 곳에
create / destroy 쌍소유권이 명확(만든 쪽의 API로 해제)
불투명 핸들(opaque pointer)내부 구조를 숨기고 단일 포인터만 노출
연속 메모리 할당2차원 배열을 T **로 쪼개 할당하지 말고 T * 한 덩어리 + 인덱스 계산(a[i*cols + j])
반환값/에러 코드 분리출력 매개변수를 줄이고 int 에러 코드를 반환
typedef로 이름 붙이기의미를 드러내 읽기 쉽게(다만 복잡도 자체는 안 줄어듦)

→ 참고: 개수 필드 + 포인터의 포인터 — 동적 문자열 배열 만들고 해제하기, 동적 할당 소유권 — caller free vs callee create·destroy 쌍, pthread_create에 구조체 포인터 넘기기 — void 포인터와 이중포인터

꼬리 질문

질문답변 요지
이중 포인터가 왜 필요한가?C는 값 전달(pass by value)이라 함수가 호출자의 포인터 변수 자체를 바꾸려면 그 포인터의 주소를 받아야 한다.
char **argv와 char *argv[]의 차이?함수 매개변수에서는 동일하다(배열이 포인터로 decay).
2차원 배열 int a[3][4]와 int **는 같은가?아니다. a는 연속된 12개 int이고 int (*)[4]로 decay한다. int **는 포인터 배열이라 메모리 모양이 다르다 — 서로 대입/캐스팅하면 안 된다.
동적 2차원 배열의 좋은 방법?한 번에 malloc(rows*cols*sizeof(T))로 연속 할당하고 a[i*cols+j]로 접근. 해제도 free 한 번.
포인터 체이싱이 느린 이유?각 단계가 메모리 접근이며 서로 의존적이라 CPU가 선행 로드(prefetch)·병렬화하기 어렵고 캐시 미스가 누적된다.

관련 문서